Project 16: Music Player with Media3 and Background Playback

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 16 min read · Updated

Service owns the player

  1. The screen connects to the service with a MediaController.
  2. Commands such as play and next go to the player in the service.
  3. The service keeps playing even if the screen is destroyed.
  4. The system shows a notification and lock screen controls from the same session.

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: setMediaItems with metadata for title and artist, and seekToNextMediaItem and seekToPreviousMediaItem.
  • 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

  1. New Empty Activity project Music, package com.example.music.
  2. Add the Media3 libraries from the Project 16 block and sync.
  3. Register the service and permissions in the manifest (see the additions file).
  4. Replace the placeholder addresses in Tracks.kt with audio you have the right to play, such as your own files or royalty-free music.
  5. 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 ExoPlayer with music audio attributes, so Android manages audio focus, and wraps it in a MediaSession.
  • The ViewModel builds a SessionToken, creates a MediaController and, 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

  1. Replace the track addresses, build the app and play for a minute in the background.
  2. Add shuffle and repeat buttons with shuffleModeEnabled and repeatMode.
  3. Show the playlist and let the user tap a track to jump to it.
  4. Add album art through MediaMetadata.setArtworkUri and 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.

Frequently asked questions

Why not play music from the ViewModel?

A ViewModel dies with its screen. A foreground service can keep running, and the system treats it as a media app with notification and button support.

Can I stream any song I find online?

Only audio you have the right to use. Use your own files, licensed music or royalty-free sources.

What about video?

Media3 plays video with the same ExoPlayer. Project 20 does it with a PlayerView.