
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:
So we will use Android tech stacks like this:
- Jetpack Compose Material3
1.3.1+ Coil2.4.0 Koin Dependency InjectionAndroidXRoomRetrofit + OkHttp3Broadcast ReceiverGlideExoplayer 3 + Foreground Servicegoogle/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
- 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) | |
| } |
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) | |
| } | |
| } |
| 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) | |
| } | |
| } | |
| } | |
| } |
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
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


