Blog Infos
Author
Published
Topics
, , , ,
Published

 

In this era of 2025 there are a lot things to do to break our boredom activities. One of them is listening to music. There are a lot of music apps favorite most e.g YouTube Music, Itunes, SoundCloud, Spotify, etc. Let’s say we talk about Spotify now, do you know how much total users of Music listener especially in Spotify? According to their official information:

As of mid-2025, Spotify has about 696 million monthly active users, including 276 million Premium (paying) subscribers.

It means that Spotify is one of most favorite music player until now. Anyway, do you curious about how to create Advance streaming music player like Spotify does? Let’s break it down.

Now I wanna share to you about how to build Advance Android Music Streaming App using Jetpack Compose and Exoplayer integrated with Gemini AI to give us music recommendation by current date.

First, this is what our app’s architecture looks like:

 

and let say we have several API provided:

  1. Genre API
  2. List of Song API
  3. Recommendation Song API

So we will use Android tech stacks like this:

  • Jetpack Compose Material3 1.3.1 + Coil 2.4.0
  • Koin Dependency Injection
  • AndroidX Room
  • Retrofit + OkHttp3
  • Broadcast Receiver
  • Glide
  • Exoplayer 3 + Foreground Service
  • google/gemini-2.0-flash-thinking-exp-1219:free

Note: in this part we are not focusing on how to create S.O.L.I.D principle architecture and how to implement it using koin. If you need to see step by step how to implement it, you can refer to this article

Step of development
  1. Create new Android Studio project (Jetpack Compose obviously) and in your build.gradle.kts (app) add these:

 

dependencies {
    // Ui development purpose
    implementation("androidx.core:core-ktx:1.9.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
    implementation("androidx.compose.runtime:runtime-livedata:1.7.8")
    implementation("androidx.activity:activity-compose:1.10.1")
    implementation(platform("androidx.compose:compose-bom:2023.03.00"))
    implementation("androidx.navigation:navigation-compose:2.8.9")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3:1.3.1")
    implementation("androidx.navigation:navigation-runtime-ktx:2.8.9")
    implementation("io.coil-kt:coil-compose:2.4.0")
    implementation("androidx.compose.foundation:foundation:1.7.8")
    implementation("com.google.android.material:material:1.12.0")

    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
    androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")

    // Retrofit core
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
    implementation("com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2")
    implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.0")

    // koin
    implementation("javax.inject:javax.inject:1")
    implementation("io.insert-koin:koin-android:3.5.3")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
    implementation("io.insert-koin:koin-androidx-compose:3.5.3") // If using Jetpack Compose
    implementation("io.insert-koin:koin-core:3.5.3")

    // room
    implementation("androidx.room:room-runtime:2.6.1")
    kapt("androidx.room:room-compiler:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")

    // exo player
    implementation("com.google.android.exoplayer:exoplayer:2.19.1")
    implementation("com.google.android.exoplayer:extension-mediasession:2.19.1")
    implementation("androidx.media:media:1.7.0")

    // glide
    annotationProcessor("com.github.bumptech.glide:compiler:4.15.1")
    implementation("com.github.bumptech.glide:glide:4.15.1")

    // broadcast receiver
    implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
}

 

2. We’ll use these permissions in AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

3. Create architechture folder like this because we’ll implement Modern Android Development (MAD)

4. Create MusicService.kt and initialize Exoplayer :

private val exoplayer: ExoPlayer by lazy {
    ExoPlayer.Builder(this).build()
}

5. Define several Exoplayer function like Play, Pause, etc

private fun playPlayback(isRefreshSamePlayback: Boolean = false) {
    if (exoplayer.currentMediaItem != MediaItem.fromUri(currentPlayingTrack?.streamedUrl.orEmpty())
        || exoplayer.playbackState == Player.STATE_IDLE
        || isRefreshSamePlayback) {

        exoplayer.setMediaItem(MediaItem.fromUri(currentPlayingTrack?.streamedUrl.orEmpty()))
        exoplayer.volume = 1f
        exoplayer.prepare()
    }

    playMusic(currentPlayingTrack?.idPk ?: 0, exoplayer.currentPosition)
    ....
}

 

private fun pausePlayback() {
    if (exoplayer.isPlaying) {
        exoplayer.pause()
    }
    ...
}

 

 

private fun stopPlayback() {
    exoplayer.release()
     .....
}

 

 

exoplayer.addListener(object : Player.Listener {
    override fun onPlaybackStateChanged(state: Int) {
        if (state == Player.STATE_READY) {
          ......
        }
    }
})

 

6. Create interface listener so it can communicate between our service and activity (play, pause, next, previous, etc)

package com.view.musicplayer.spotifyclone.service.listener
import android.content.Context
interface ServiceStartOrStopListener {
fun onPlay(context: Context, musicId: String)
fun onRestart(context: Context)
fun onNext(context: Context)
fun onPrevious(context: Context)
fun onPause(context: Context)
fun onStop(context: Context)
fun onShuffle(context: Context, isEnable: Boolean)
fun onRepeat(context: Context, repeatMode: Int)
}
view raw gistfile1.txt hosted with ❤ by GitHub

7. Create Intent to service from Activity and from Foreground Notification (if our app is minimized)

package com.view.musicplayer.spotifyclone.service.receiver
import android.content.Context
import android.content.Intent
import com.view.musicplayer.spotifyclone.service.MusicService
import com.view.musicplayer.spotifyclone.service.listener.ServiceStartOrStopListener
class ActivityToServiceReceiver: ServiceStartOrStopListener {
override fun onPlay(
context: Context,
musicId: String
) {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.TAG.MUSIC_ID, musicId)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.START_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
override fun onPause(context: Context) {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.PAUSE_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
override fun onRestart(context: Context) {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.RESTART_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
override fun onNext(context: Context) {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.NEXT_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
override fun onPrevious(context: Context) {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.PREV_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
override fun onStop(context: Context) {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.STOP_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
override fun onShuffle(context: Context, isEnable: Boolean) {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.SHUFFLE_MODE)
serviceIntent.putExtra(MusicService.TAG.IS_SHUFFLE, isEnable)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
override fun onRepeat(context: Context, repeatMode: Int) {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.REPEAT_MODE)
serviceIntent.putExtra(MusicService.TAG.REPEAT_MODE, repeatMode)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
}
view raw gistfile1.txt hosted with ❤ by GitHub
package com.view.musicplayer.spotifyclone.service.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.view.musicplayer.spotifyclone.service.MusicService
class NotificationToServiceReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
MusicService.ActionNotification.ACTION_PLAY -> {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.START_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
MusicService.ActionNotification.ACTION_PAUSE -> {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.PAUSE_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
MusicService.ActionNotification.ACTION_NEXT -> {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.NEXT_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
MusicService.ActionNotification.ACTION_PREV -> {
val serviceIntent = Intent(context, MusicService::class.java)
serviceIntent.putExtra(MusicService.ActionKey.ACTION, MusicService.ActionDetail.PREV_MODE)
serviceIntent.action = MusicService.Notification.START_FOREGROUND_ACTION
context.startService(serviceIntent)
}
}
}
}
view raw gistfile1.txt hosted with ❤ by GitHub

8. Add this code to our MainActivity.ktso it can receive any changes from our service and update it to compose UI (we want to update track progress, music title, music position, etc to our app):

private fun registerBroadcast() {
    broadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            val bundle = intent.extras

            viewModel.currentTrack.postValue(bundle?.getParcelable(MusicService.INTENT.PENDING_MUSIC_DATA))
            viewModel.currentTrackStatus.postValue(bundle?.getString(MusicService.INTENT.PENDING_MUSIC_STATUS) ?: "")
            viewModel.currentTrackDuration.postValue(bundle?.getLong(MusicService.INTENT.PENDING_DURATION) ?: 0L)
            viewModel.currentTrackDurationTotal.postValue(bundle?.getLong(MusicService.INTENT.PENDING_DURATION_TOTAL) ?: 0L)
            viewModel.currentTrackDurationText.postValue(bundle?.getString(MusicService.INTENT.PENDING_DURATION_TEXT) ?: "")
            viewModel.currentTrackDurationTotalText.postValue(bundle?.getString(MusicService.INTENT.PENDING_DURATION_TOTAL_TEXT) ?: "")
        }
    }

    LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter(MusicService.Notification.BROADCAST_NAME))
}

 

@Composable
fun MainPage(
    viewModel: MainActivityViewModel,
    listener: ServiceStartOrStopListener,
    context: Context
) {
    val navController = rememberNavController()
    var isShowPlayerButton by rememberSaveable { mutableStateOf(false) }
    var isShuffle by rememberSaveable { mutableStateOf(false) }

    val userLogin by viewModel.userData.observeAsState()
    val successLogin by viewModel.successLogin.observeAsState()
    val currentPlaying by viewModel.currentTrack.observeAsState()
    val playerStatus by viewModel.currentTrackStatus.observeAsState()
    val trackProgress by viewModel.currentTrackDuration.observeAsState()
    val trackProgressTotal by viewModel.currentTrackDurationTotal.observeAsState()
    val trackProgressText by viewModel.currentTrackDurationText.observeAsState()
    val trackProgressTotalText by viewModel.currentTrackDurationTotalText.observeAsState()
    .
    .
    .
}

 

9. For Gemini AI, I use API from open router and we put it in gradle.properties

api.openrouter=https://openrouter.ai/api/v1/sk-or-v1-YOUR_APIKEY

10. in MainActivity.kt , declare MainActivityViewModel.kt , BroadcastReceiver, and ServiceStartOrStopListener interface

class MainActivity : ComponentActivity() {
    private val notificationListener: ServiceStartOrStopListener by inject()
    private lateinit var broadcastReceiver: BroadcastReceiver
    private val viewModel: MainActivityViewModel by viewModel()
    .
    .
    .
}

11. in MainActivityViewModel.kt , add open router request before hit API

private fun getOpenRouterRequest(prompt: String) : OpenRouterRequest {
   val message = ArrayList<OpenRouterMessage>()

    message.add(
       OpenRouterMessage(
           role = Constants.AI_ROLE,
           content = prompt
       )
   )

    return OpenRouterRequest(
       model = Constants.AI_MODEL,
       messages = message
   )
}

12. Add function runOpenAi() to hit Gemini AI to get AI recommendation song title (Because of my mechanism is I don’t want to hit API multiple times in short time, I prefer to store it to Room DB and read it inside service

class MainActivityViewModel(val db: AppDb,
                            val api: Api,
                            val openRouterApi: OpenRouterApi): BaseViewModel<Any?>() {

internal var finishLoad = SingleLiveEvent<Boolean>().apply { value = false }
internal var successLogin = SingleLiveEvent<Boolean>().apply { value = false }
internal var userData = SingleLiveEvent<User>().apply { value = null }
.
.
.

private suspend fun runOpenAi(context: Context,
                                  prompt: String,
                                  listRecommend: List<SongRecommendation>,
                                  listTrack: List<Track>
  ) {
      executeJob(context) {
          safeScopeFun(context).launch(Dispatchers.IO) {
              flowOnValue(
                 onRunning = { openRouterApi.getResponse(getOpenRouterRequest(prompt)) },
                 onError = {
                     val listTrackChunked = listTrack.chunked(listTrack.size / listRecommend.size)

                     listRecommend.mapIndexed { index, listRecommend ->
                         val results = SongRecommendation(
                             idPk = index,
                             id = listRecommend.id,
                             title = listRecommend.title,
                             listTrack = listTrackChunked[index] as ArrayList<Track>
                         )
                         db.recommendDao().insert(results)
                     }

                     db.openAiDao().insert(OpenAIFlagDb(lastHitDate = getCurrentDate().orEmpty()))
                     finishLoad.postValue(true)
                 }
              ).collectLatest { response ->
                  val listExtracted = response.choices.first().message.content.extractTextIntoDesiredListText()
                  val listTrackChunked = listTrack.chunked(listTrack.size / listRecommend.size)

                  listRecommend.mapIndexed { index, listRecommend ->
                      val results = SongRecommendation(
                          idPk = index,
                          id = listRecommend.id,
                          title = listExtracted[index],
                          listTrack = listTrackChunked[index] as ArrayList<Track>
                      )
                      db.recommendDao().insert(results)
                  }

                  db.openAiDao().insert(OpenAIFlagDb(lastHitDate = getCurrentDate().orEmpty()))
                  finishLoad.postValue(true)
              }
          }
      }
  }
.
.
.
}

13. Read song data we have previously add it to Room DB and we use CoroutineScope.IO because service will run under foreground service and it is counted as run in background.

class MusicService : Service() {
  private val exoplayer: ExoPlayer by lazy {
      ExoPlayer.Builder(this).build()
  }
  private val serviceJob = SupervisorJob()
  private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
  .
  .
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
      serviceScope.launch {
          if (musicId.isNotEmpty()) {
              currentPlayingTrack = RoomModule.provideDB(applicationContext).trackDao().getTrackById(musicId)
          }
      }
      .
      .
      .
      return START_NOT_STICKY
  }
}

14. After all finished, Don’t forget to unregister our receiver if the app’s activity is destroyed

private fun unRegisterBroadcast() {
    LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver)
}

15. And also Don’t forget to stopForeground and stopSelf if music is stop so it won’t duplicate re-create service.

private fun killService() {
    stopSelf()
    stopForeground(true)
}

 

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Note: Exoplayer should be run in Service not in Activity because if you run it in Activity, Service need to run it also so it can run in foreground service and it will lead to duplicate Exoplayer service running (My past experience did it 🙁 ).

Finally, you can see the rest sample using full version of this implementation with sample source code and demo here

Happy coding 🙂

This article was previously published on proandroiddev.com

Menu