When I set out to build Ambient Music, I wanted to create something that “just works” — a music player you could start and forget about. No playlists to manage, no complex UI to navigate. Just hit play and let it handle the rest.
What I didn’t expect was how much complexity would be hidden behind that simple user experience. The core of the app — the playback service that keeps music running seamlessly in the background — took significant time to perfect. In this post, I’ll walk through the architecture, design decisions, and implementation details that make it all work.
The Architecture
At its heart, Ambient Music is built around a foreground service that manages continuous playback. The service needs to:
- Handle playback state across app lifecycle events
- Integrate with Android’s media system (MediaSession, notifications, Quick Settings)
- Manage genre-based playlists dynamically
- Handle audio focus and interruptions gracefully
- Keep the UI in sync with playback state
The main component that orchestrates all of this is MusicPlaybackService, a foreground service that uses ExoPlayer for media playback and Media3’s MediaSession for system integration.
SongsRepo: Dynamic Track Loading
Before the playback service can play music, it needs tracks to play. This is where SongsRepo comes in — a singleton object that manages fetching, caching, and providing song data to the rest of the app.
Remote-First with Local Fallback
The app uses a remote-first approach: tracks are fetched from a remote JSON file hosted at https://www.ambient-music.online/songs.json. This allows updating the playlist without requiring app updates. However, the app also maintains a local cache for offline functionality.
object SongsRepo {
private const val REMOTE_SONGS_URL = "https://www.ambient-music.online/songs.json"
private const val LOCAL_CACHE_FILE_NAME = "songs_cache.json"
@Volatile private var internalLoadedSongs: List<SongAsset> = emptyList()
var currentTrackIndex = 0
private set
}
The @Volatile annotation ensures thread-safe access, which is critical since the repository is accessed from multiple threads (UI thread, background coroutines, service thread).
Data Model
Songs are represented as SongAsset data classes using Kotlin’s serialization:
@Serializable
data class SongAsset(
val url: String,
val title: String,
val artist: String,
val albumArtUrl: String? = null,
val genre: String? = null,
)
The JSON parser is configured to be lenient and ignore unknown keys, making it resilient to schema changes:
private val jsonParser = Json {
ignoreUnknownKeys = true
isLenient = true
}
Initialization and Refresh
The initializeAndRefresh method handles the complete refresh cycle:
- Clear local cache - Ensures fresh data on each refresh
- Fetch from remote - Downloads the latest JSON
- Parse and update - Deserializes the JSON and updates the internal list
- Save to cache - Persists the data locally for offline use
fun initializeAndRefresh(context: Context, onFinished: ((Boolean, String) -> Unit)? = null) {
CoroutineScope(Dispatchers.IO).launch {
// Clear Local Cache
clearCache(context)
// Attempt to fetch the JSON from remote
try {
val request = Request.Builder().url(REMOTE_SONGS_URL).build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val jsonString = response.body.string()
val remoteSongs = jsonParser.decodeFromString<List<SongAsset>>(jsonString)
synchronized(this@SongsRepo) {
internalLoadedSongs = remoteSongs
if (currentTrackIndex >= internalLoadedSongs.size
&& internalLoadedSongs.isNotEmpty()) {
currentTrackIndex = 0
}
saveToCache(context, jsonString)
}
overallSuccess = true
}
}
} catch (e: IOException) {
// Handle network errors
} catch (e: SerializationException) {
// Handle parsing errors
}
withContext(Dispatchers.Main) {
onFinished?.invoke(overallSuccess, finalStatusMessage)
}
}
}
The method runs entirely on a background thread (Dispatchers.IO) and uses synchronized blocks to ensure thread-safe updates to the internal state. The callback is switched back to the main thread for UI updates.
Thread-Safe Access
All public accessors use synchronized blocks to ensure thread safety:
val songs: List<SongAsset>
get() = synchronized(this@SongsRepo) { internalLoadedSongs }
fun getCurrentSong(): SongAsset? =
synchronized(this@SongsRepo) {
internalLoadedSongs.getOrNull(currentTrackIndex)
}
fun selectTrack(index: Int) {
synchronized(this@SongsRepo) {
if (index >= 0 && index < internalLoadedSongs.size) {
currentTrackIndex = index
}
}
}
This ensures that even when the repository is being updated from a background thread, reads from the UI thread or service thread remain consistent.
The Playback Service: Core Design
The MusicPlaybackService is where most of the complexity lives. Let me break down the key design decisions and how they work together.
Genre-Based Playback
One of the app’s key features is genre-based playback. Users can select different genres (chill, calm, sleep, focus, serenity) and the service filters the playlist accordingly:
private fun playGenre(genre: String) {
currentPlaylistGenre = genre
val genreSongs = SongsRepo.songs.filter {
it.genre.equals(genre, ignoreCase = true)
}
if (genreSongs.isEmpty()) {
Log.w(TAG, "No songs found for genre: $genre")
return
}
val mediaItems = genreSongs.map { songData ->
val metadataBuilder = MediaMetadata.Builder()
.setTitle(songData.title)
.setArtist(songData.artist)
songData.albumArtUrl?.let {
metadataBuilder.setArtworkUri(it.toUri())
}
MediaItem.Builder()
.setUri(songData.url)
.setMediaId(songData.url)
.setMediaMetadata(metadataBuilder.build())
.build()
}
exoPlayer?.shuffleModeEnabled = true
val startIndex = genreSongs.indices.random()
exoPlayer?.setMediaItems(mediaItems, startIndex, C.TIME_UNSET)
exoPlayer?.prepare()
exoPlayer?.play()
isPlaylistSet = true
}
The genre filtering happens at runtime, which means the same JSON file can contain multiple genres and the app dynamically creates playlists based on user selection. Shuffle is enabled by default, and playback starts from a random index to provide variety.
Asynchronous Album Art Loading
Album art is loaded asynchronously using Coil (a modern image loading library for Android). This prevents blocking the main thread while fetching images:
private fun fetchArtworkAsync(artworkUri: Uri) {
val request = ImageRequest.Builder(this)
.data(artworkUri)
.target(
onSuccess = { result: Drawable ->
currentAlbumArt = result.toBitmap()
updateNotification()
},
onError = {
currentAlbumArt = null
updateNotification()
}
)
.build()
imageLoader.enqueue(request)
}
The artwork is fetched whenever a new track starts playing, triggered by the onMediaItemTransition callback:
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val newIndex = this@apply.currentMediaItemIndex
SongsRepo.selectTrack(newIndex)
// Clear old art and fetch new art on transition
currentAlbumArt = null
mediaItem?.mediaMetadata?.artworkUri?.let {
fetchArtworkAsync(it)
}
updateNotification()
}
Notification Management
The notification serves multiple purposes:
- Keeps the service running as a foreground service
- Provides media controls to the user
- Displays current track information
- Integrates with MediaSession for system-wide controls
@OptIn(UnstableApi::class)
private fun createNotification(): Notification {
val currentExoPlayerMediaItem = exoPlayer?.currentMediaItem
val currentMediaMetadata = currentExoPlayerMediaItem?.mediaMetadata
val songFromRepo = SongsRepo.getCurrentSong()
val title = currentMediaMetadata?.title?.toString()?.takeIf { it.isNotBlank() }
?: songFromRepo?.title
?: getString(R.string.qs_tile_notification_title_unknown)
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setContentText(artist)
.setSmallIcon(R.drawable.ic_music_note)
.setLargeIcon(currentAlbumArt)
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
mediaSession?.let { session ->
val mediaStyle = MediaStyleNotificationHelper.MediaStyle(session)
.setShowActionsInCompactView(0, 1)
builder.setStyle(mediaStyle)
}
return builder.build()
}
The notification uses MediaStyle, which integrates with MediaSession to provide standard media controls. The setOngoing(true) ensures the notification can’t be swiped away while music is playing.
Design Challenges and Solutions
Challenge 1: State Synchronization
Keeping the UI, Quick Settings tile, and service state in sync was tricky. The solution was using companion object properties with @Volatile and a utility class (TileStateUtil) that can request tile updates from anywhere.
Challenge 2: Genre Switching
When a user switches genres while music is playing, we need to avoid mixing tracks from different genres. The currentPlaylistGenre property tracks the active genre, and genre-specific actions always filter the playlist before setting it.
Challenge 3: Notification Updates
Notifications need to update smoothly when tracks change. The updateNotification method is called on every state change, but it only calls startForeground when not playing (to avoid unnecessary foreground service restarts) and uses notify when playing.
Challenge 4: Error Recovery
If ExoPlayer becomes null or encounters an error, the service attempts recovery:
if (exoPlayer == null) {
Log.e(TAG, "ExoPlayer is null in togglePlayback. Aborting.")
// Attempt recovery
initializePlayerAndSession()
prepareAndSetPlaylist()
return
}
Try It Out
The app is available on the Google Play Store and the source code is open source on GitHub.
The playback service represents months of iteration, testing, and refinement. What looks like a simple “play music” feature is actually a complex system managing state, resources, and system integration. But that’s the beauty of good software — the complexity is hidden, and the user experience is simple.
If you’re building something similar or have questions about the implementation, feel free to check out the source code or open an issue on GitHub!