Audio player library built with Kotlin Multiplatform (KMP). It provides a consistent API for music playback functionality across both Android and iOS.
- Supports Kotlin Multiplatform (Android, iOS)
- Consistent music playback experience with a unified API
- Media caching support
- Playlist management (add, remove, reorder, replace)
- Dynamic music replacement during playback
- Shuffle and repeat mode support
- Sleep timer support (fixed duration / current track end)
- Playback state and position monitoring
- Background playback support
- Control Center (iOS) and Media Notification (Android) integration
- Per-track HTTP request headers for protected streaming URLs
For Kotlin Multiplatform, add the dependency below to your module's build.gradle.kts file:
sourceSets {
commonMain.dependencies {
implementation("io.github.moonggae:kmedia:$kmedia_version")
}
}Add the following permissions and service to your AndroidManifest.xml:
<!-- Permissions --> <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" /> <!-- Service registration --> <service android:name="io.github.moonggae.kmedia.session.PlaybackService" android:exported="false" android:foregroundServiceType="mediaPlayback"> <intent-filter> <action android:name="androidx.media3.session.MediaSessionService" /> </intent-filter> </service>
Add the following to your Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key> <array> <string>io.github.moonggae.kmedia.assetDownloadSession</string> </array> <key>UIBackgroundModes</key> <array> <string>audio</string> <string>processing</string> </array>
Initialize KMedia once when your app starts. On Android, call it from Application.onCreate() so
PlaybackService can be created safely before any UI screen.
class App : Application() { override fun onCreate() { super.onCreate() KMedia.initialize( context = this, config = KMediaConfig( cacheEnabled = true, cacheSizeMb = 1024 ) ) } }
On Android, you can customize the activity Intent used when the media notification is tapped.
The Android-specific overload keeps Android types out of the common KMediaConfig.
import android.net.Uri import io.github.moonggae.kmedia.KMediaAndroidConfig import io.github.moonggae.kmedia.initialize KMedia.initialize( context = this, config = KMediaConfig( cacheEnabled = true, cacheSizeMb = 1024 ), androidConfig = KMediaAndroidConfig( sessionActivityIntentProvider = { _, defaultIntent -> defaultIntent?.apply { data = Uri.parse("kmedia://session/open?source=notification") putExtra("source", "media_notification") } } ) )
Handle the intent from both onCreate() and onNewIntent() if your app needs the data or extras.
On iOS, call the same initialization API from your app entry point before creating the player UI.
KMedia.initialize( context = Unit, config = KMediaConfig( cacheEnabled = true, cacheSizeMb = 1024 ) )
KMedia can be used as follows:
// Create a KMedia instance val media = KMedia.create() // Get music list val musics = SampleMusicRepository().getSampleMusicList() // Start playback media.player.playMusics(musics, startIndex = 0) // Play/Pause media.player.play() media.player.pause() // Previous/Next track media.player.previous() media.player.next() // Seek to position media.player.seekTo(positionMs = 30000)
For protected audio streams, attach request headers to each Music item. Headers are
kept in memory for playback and are not used as cache keys.
val protectedMusic = Music( id = "protected-track-1", title = "Protected Track", uri = "https://media.example.com/tracks/protected-track-1/stream", cacheKey = "protected-track-1-audio", mimeType = "audio/mpeg", requestHeaders = mapOf( "Authorization" to "Bearer <token>", "X-Playback-Token" to "<playback-token>" ) ) media.player.playMusics(listOf(protectedMusic), startIndex = 0)
Call KMedia.initialize(...) once per app process before creating any KMedia instance.
On Android, call it from Application.onCreate() so PlaybackService can resolve its
dependencies even when it is created before UI.
KMedia.create() returns the process-wide KMedia facade.
KMedia.release() releases resources owned by that facade, such as sleep timer jobs and
the playback controller connection. It does not shut down KMedia's internal dependency graph.
Reinitializing KMedia with a different config in the same process is not supported.
You can monitor the playback state through Flow:
val playbackState by media.playbackState.collectAsState() // Check current playback status when (playbackState.playingStatus) { PlayingStatus.PLAYING -> // Playing PlayingStatus.PAUSED -> // Paused PlayingStatus.BUFFERING -> // Buffering PlayingStatus.IDLE -> // Not ready PlayingStatus.ENDED -> // Playback completed } // Current position and duration val position = playbackState.position val duration = playbackState.duration // Current music information val currentMusic = playbackState.music val currentIndex = playbackState.currentIndex
// Add music to current playlist media.player.addMusic(newMusic) // Add multiple musics to playlist media.player.addMusics(musicList) // Remove music at specific index media.player.removeMusic(index = 2) // Clear entire playlist media.player.clearPlaylist()
You can replace music in the current playlist without interrupting playback:
// Replace music at specific index val updatedMusic = currentMusic.copy( uri = "https://example.com/new-track.mp3", title = "Updated Title" ) media.player.replaceMusic(index = 0, music = updatedMusic) // Replace current playing music val playbackState by media.playbackState.collectAsState() playbackState.music?.let { currentMusic -> if (needsUpdate(currentMusic)) { val newMusic = currentMusic.copy( uri = getUpdatedUri(currentMusic.id) ) media.player.replaceMusic(playbackState.currentIndex, newMusic) } }
Use Cases for Music Replacement:
- Loading high-quality audio URLs after initial playlist setup
- Updating expired streaming URLs
- Switching between different audio quality versions
- Dynamic content updates during playback
// Set repeat mode media.player.setRepeatMode(RepeatMode.REPEAT_MODE_ONE) // Repeat one media.player.setRepeatMode(RepeatMode.REPEAT_MODE_ALL) // Repeat all media.player.setRepeatMode(RepeatMode.REPEAT_MODE_OFF) // No repeat // Set shuffle mode media.player.setShuffleMode(true) // Enable shuffle media.player.setShuffleMode(false) // Disable shuffle
KMedia includes a built-in sleep timer with smooth fade-out before pause.
// Start timer with fixed duration (milliseconds) media.sleepTimer.start(durationMs = 15 * 60 * 1000L) // 15 minutes // Stop playback at the end of current track media.sleepTimer.startUntilCurrentTrackEnd() // Cancel timer media.sleepTimer.cancel()
You can observe sleep timer state via Flow:
val sleepTimerState by media.sleepTimer.state.collectAsState() if (sleepTimerState.isActive) { println("Mode: ${sleepTimerState.mode}") println("Remaining: ${sleepTimerState.remainingMs} ms") }
Notes:
- Duration timer uses monotonic time for stable countdown behavior.
- "Current track end" mode tracks playback progress and
ENDEDstate. - On expiry, playback volume fades out smoothly, then playback is paused.
// Check cache usage val cacheUsage by media.cache.usedSizeBytes.collectAsState(initial = 0L) // Observe cache statuses val cacheStatuses by media.cache.statuses.collectAsState() // Cache specific music media.cache.preCacheMusic(url = "https://example.com/music.mp3", key = "music1") // Cache protected music without putting tokens in the URL media.cache.preCacheMusic( url = "https://example.com/audio/asmr-1/stream", key = "audio-file-1", requestHeaders = protectedMusic.requestHeaders ) // Check cache status val isCached = media.cache.checkMusicCached("music1") // Remove cache media.cache.removeCachedMusic("music1") // Clear all cache media.cache.clearCache()
KMedia exposes cache status changes as state, so UI code can subscribe without registering
callbacks from Application.
Usage example:
val cacheStatuses by media.cache.statuses.collectAsState() when (cacheStatuses["music1"]) { CacheStatus.NONE -> println("music1: No cache") CacheStatus.PARTIALLY_CACHED -> println("music1: Caching in progress") CacheStatus.FULLY_CACHED -> println("music1: Caching completed") null -> println("music1: No cache status yet") }
Playback analytics are exposed as an in-process event stream.
Events are delivered to active collectors only. KMedia does not persist analytics events, does not replay historical events to new collectors, and does not guarantee delivery after process death. If analytics delivery must be guaranteed, collect this stream from an application-level coroutine and persist or upload events in your app layer.
Usage example:
LaunchedEffect(media) { media.analyticsEvents.collect { event -> val playPercentage = (event.totalPlayTimeMs.toFloat() / event.durationMs.toFloat()) * 100 println("${event.musicId} playback completed: $playPercentage% played") // You can send analytics data to server here. } }