A music player has one special requirement: the music must keep playing when the user leaves the app. A screen cannot do that, because Android may destroy it. The player must live in a service. Google's Media3 library makes this much easier. It gives you ExoPlayer, a powerful player, and a MediaSessionService, which creates the media notification and lock screen controls for you.
The design has two sides. The service owns the player. The screen is a controller: it connects to the service, sends commands such as play and next, and shows the state it receives. That separation is why playback survives rotation, closing the screen and even the app being swiped away while music plays.
Concepts in this project
- ExoPlayer: plays audio and video from URLs and files, and handles buffering and formats.
- MediaSessionService: a foreground service that publishes the player to the system. The notification, Bluetooth buttons and lock screen all talk to it.
- MediaController: how the screen connects to the session. It is created asynchronously, so the ViewModel waits for the future to finish.
- Playlist:
setMediaItemswith metadata for title and artist, andseekToNextMediaItemandseekToPreviousMediaItem. - Position polling: the player does not push the playback position every moment, so the ViewModel reads it twice a second for the seek bar.
- Audio focus and noise: the player settings pause for calls and when headphones are unplugged.
Create the project
- New Empty Activity project
Music, packagecom.example.music. - Add the Media3 libraries from the Project 16 block and sync.
- Register the service and permissions in the manifest (see the additions file).
- Replace the placeholder addresses in
Tracks.ktwith audio you have the right to play, such as your own files or royalty-free music. - Create the files below.
`Gradle dependencies for projects 11 to 21 (use the block for this project)`
// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")
// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.
// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.
// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.
// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Music/app/src/main/java/com/example/music/
PlaybackService.kt owns the ExoPlayer and the media session
Tracks.kt playlist, MediaItem helper, time format
PlayerViewModel.kt MediaController, UI state, commands
PlayerScreen.kt title, seek bar, buttons
MainActivity.kt
app/src/test/.../MusicTest.kt
The code
`app/src/main/AndroidManifest.xml (add these lines)`
<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.POST_NOTIFICATIONS" />
<!-- inside <application> -->
<service
android:name=".PlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
`app/src/main/java/com/example/music/PlaybackService.kt`
package com.example.music
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
// A MediaSessionService keeps music playing when the app is in the background
// and shows the standard media notification and lock screen controls.
class PlaybackService : MediaSessionService() {
private var session: MediaSession? = null
override fun onCreate() {
super.onCreate()
val player = ExoPlayer.Builder(this)
.setAudioAttributes(
AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build(),
true,
)
.setHandleAudioBecomingNoisy(true)
.build()
session = MediaSession.Builder(this, player).build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
override fun onDestroy() {
session?.run {
player.release()
release()
}
session = null
super.onDestroy()
}
}
`app/src/main/java/com/example/music/Tracks.kt`
package com.example.music
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
data class Track(val title: String, val artist: String, val url: String)
// Use audio you have the right to stream. These addresses are placeholders: replace them
// with your own files or with royalty-free audio you have checked.
object Tracks {
val all = listOf(
Track("Sample One", "Your Artist", "https://example.com/audio/one.mp3"),
Track("Sample Two", "Your Artist", "https://example.com/audio/two.mp3"),
Track("Sample Three", "Your Artist", "https://example.com/audio/three.mp3"),
)
}
fun Track.toMediaItem(): MediaItem {
return MediaItem.Builder()
.setUri(url)
.setMediaMetadata(MediaMetadata.Builder().setTitle(title).setArtist(artist).build())
.build()
}
fun formatTime(ms: Long): String {
val total = (ms / 1000).coerceAtLeast(0)
return (total / 60).toString() + ":" + (total % 60).toString().padStart(2, '0')
}
`app/src/main/java/com/example/music/PlayerViewModel.kt`
package com.example.music
import android.app.Application
import android.content.ComponentName
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class PlayerUi(
val title: String = "",
val artist: String = "",
val playing: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
val connected: Boolean = false,
)
class PlayerViewModel(app: Application) : AndroidViewModel(app) {
private val _ui = MutableStateFlow(PlayerUi())
val ui: StateFlow<PlayerUi> = _ui.asStateFlow()
private var controller: MediaController? = null
init {
val token = SessionToken(app, ComponentName(app, PlaybackService::class.java))
val future = MediaController.Builder(app, token).buildAsync()
future.addListener({
val c = future.get()
controller = c
c.setMediaItems(Tracks.all.map { it.toMediaItem() })
c.prepare()
c.addListener(object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
publish(player)
}
})
publish(c)
_ui.update { it.copy(connected = true) }
}, MoreExecutors.directExecutor())
// the position is not pushed by the player, so poll it while the screen is alive
viewModelScope.launch {
while (true) {
controller?.let { publish(it) }
delay(500)
}
}
}
private fun publish(player: Player) {
val meta = player.mediaMetadata
_ui.update {
it.copy(
title = meta.title?.toString().orEmpty(),
artist = meta.artist?.toString().orEmpty(),
playing = player.isPlaying,
positionMs = player.currentPosition,
durationMs = player.duration.coerceAtLeast(0),
)
}
}
fun toggle() {
controller?.let { if (it.isPlaying) it.pause() else it.play() }
}
fun next() {
controller?.seekToNextMediaItem()
}
fun previous() {
controller?.seekToPreviousMediaItem()
}
fun seekTo(ms: Long) {
controller?.seekTo(ms)
}
override fun onCleared() {
controller?.release()
}
}
`app/src/main/java/com/example/music/PlayerScreen.kt`
package com.example.music
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun PlayerScreen(vm: PlayerViewModel = viewModel()) {
val ui by vm.ui.collectAsState()
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(ui.title.ifEmpty { "Loading..." }, style = MaterialTheme.typography.headlineSmall)
Text(ui.artist)
Slider(
value = ui.positionMs.toFloat(),
onValueChange = { vm.seekTo(it.toLong()) },
valueRange = 0f..ui.durationMs.coerceAtLeast(1).toFloat(),
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatTime(ui.positionMs))
Text(formatTime(ui.durationMs))
}
Row(
Modifier.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(onClick = vm::previous) { Text("Prev") }
Button(onClick = vm::toggle, enabled = ui.connected) { Text(if (ui.playing) "Pause" else "Play") }
OutlinedButton(onClick = vm::next) { Text("Next") }
}
}
}
`app/src/main/java/com/example/music/MainActivity.kt`
package com.example.music
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
PlayerScreen()
}
}
}
}
`app/src/test/java/com/example/music/MusicTest.kt`
package com.example.music
import org.junit.Assert.assertEquals
import org.junit.Test
class MusicTest {
@Test
fun timeFormat() {
assertEquals("0:00", formatTime(0))
assertEquals("1:05", formatTime(65_000))
assertEquals("10:00", formatTime(600_000))
assertEquals("0:00", formatTime(-5))
}
}
How it works
- The service creates an
ExoPlayerwith music audio attributes, so Android manages audio focus, and wraps it in aMediaSession. - The ViewModel builds a
SessionToken, creates aMediaControllerand, once connected, loads the playlist and prepares it. - A player listener updates the UI state when the track or play state changes, and a small loop refreshes the position.
- The screen's buttons call the controller. The seek bar sends the new position to the player when the user drags.
Expected output
The screen shows the first track's title and artist, a seek bar, the times 0:00 and the track length, and Prev, Play and Next buttons. Press Play: audio starts and the seek bar moves. Press Home: the music keeps playing and a media notification appears with play and pause. Pull down the notification and press Next: the track changes and the app shows the new title when you return. With placeholder addresses that do not exist, the player reports an error and nothing plays, so replace them first.
Your task
- Replace the track addresses, build the app and play for a minute in the background.
- Add shuffle and repeat buttons with
shuffleModeEnabledandrepeatMode. - Show the playlist and let the user tap a track to jump to it.
- Add album art through
MediaMetadata.setArtworkUriand show it with Coil.
How to check it worked
- Music keeps playing after you leave the app.
- Notification buttons control playback.
- You can explain why the player lives in a service.