Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Strumming up a music player with CMP 🤘

Strumming up a music player with CMP 🤘

Building a customer-facing music player requires a high level of precision in terms of design and music controllers. In this talk, we’ll level up the volume on creativity as we design and develop a fully functional music player with Compose Multiplatform. From crafting a UI that’s in tune with user needs to building a music controller that never misses a beat, this session will have you grooving to the rhythm of Kotlin Multiplatform 💃🕺

Avatar for Renaud MATHIEU

Renaud MATHIEU

October 01, 2025
Tweet

More Decks by Renaud MATHIEU

Other Decks in Programming

Transcript

  1. Renaud Mathieu Strumming up a music player with CMP 🤘

    Droidcon Berlin 2025 @renaudmathieu.com
  2. + +

  3. Target s A target is a part of the build

    responsible for compiling, testing, and packaging a piece of software aimed at one of the supported platforms.
  4. •Kotlin/JVM Bytecode for the JVM (Android, servers, desktop). •Kotlin/JS JavaScript

    or WASM (frontend web, Node.js). •Kotlin/Native Binaire natif LLVM (iOS, desktop, Linux, Windows). •Kotlin/WASM WebAssembly (browser, runtimes)
  5. Slot Table The Slot Table is an optimized data structure.

    • It saves the hierarchy of Composables. • It optimizes recomposition (avoid rebuilding everything for every change). • It matches current Composables with previous versions.
  6. 1. Install Android Studio or IntelliJ IDEA 2.Install Kotlin Multiplatform

    IDE plugin 3. Set up ANDROID_HOME 4.Install Xcode Kotlin Multiplatform Getting Started
  7. Kotlin source code org.jetbrains.kotlin.multiplatform • Enable multiplatform targets in Gradle

    (Android, iOS, JVM, JS, WASM). • De fi ne source sets (commonMain, androidMain, iosMain, etc.). • Apply the right Kotlin compiler per target (JVM, Native, JS). • Link common and platform code into deliverables (APK, iOS Framework, JAR). • Manage dependencies with automatic platform-speci fi c resolution.
  8. Kotlin source code Target SourceSet JVM, Android, iOS, JavaScript, …

    commonMain, jvmMain, androidMain kotlin { androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi :: class) compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } iosX64() iosArm64() iosSimulatorArm64() jvm() sourceSets { androidMain.dependencies { } commonMain.dependencies { } commonTest.dependencies { } jvmMain.dependencies { } } }
  9. Kotlin source code commonMain androidMain iosMain jvmMain interface Platform {

    val name: String } expect fun getPlatform(): Platform class JVMPlatform : Platform { override val name: String = "java" } actual fun getPlatform(): Platform = JVMPlatform() class AndroidPlatform : Platform { override val name: String = "Android ${Build.VERSION.SDK_INT}" } actual fun getPlatform(): Platform = AndroidPlatform() class IOSPlatform: Platform { override val name: String = UIDevice.currentDevice.systemName() } actual fun getPlatform(): Platform = IOSPlatform()
  10. fun Example(name: String, composer: Composer?, changed: Int) { // Start

    of composable group, unique key associated with Example() val startToken = composer.startRestartGroup( /* key */ 123456) // Propagation of change bitmask for the 'name' parameter var dirty = changed if (dirty and 0x0E = = 0) { dirty = dirty or if (composer.changed(name)) 0x02 else 0x04 } // Change verification: if no change and skipping possible, we skip the body if ((dirty and 0x0B) != 0 || !composer.skipping) { / / Actual body of the function Text("Hello $name", composer, / * changed * / 0x0) } else { composer.skipToGroupEnd() } // End of composable group, with recording of the recomposition lambda composer.endRestartGroup() ?. updateScope { comp -> Example(name, comp, dirty or 0x01) } }
  11. fun Example(name: String, composer: Composer?, changed: Int) { // Start

    of composable group, unique key associated with Example() val startToken = composer.startRestartGroup( /* key */ 123456) // Propagation of change bitmask for the 'name' parameter var dirty = changed if (dirty and 0x0E = = 0) { dirty = dirty or if (composer.changed(name)) 0x02 else 0x04 } // Change verification: if no change and skipping possible, we skip the body if ((dirty and 0x0B) != 0 || !composer.skipping) { / / Actual body of the function Text("Hello $name", composer, / * changed * / 0x0) } else { composer.skipToGroupEnd() } // End of composable group, with recording of the recomposition lambda composer.endRestartGroup() ?. updateScope { comp -> Example(name, comp, dirty or 0x01) } }
  12. fun Example(name: String, composer: Composer?, changed: Int) { // Start

    of composable group, unique key associated with Example() val startToken = composer.startRestartGroup( /* key */ 123456) // Propagation of change bitmask for the 'name' parameter var dirty = changed if (dirty and 0x0E = = 0) { dirty = dirty or if (composer.changed(name)) 0x02 else 0x04 } // Change verification: if no change and skipping possible, we skip the body if ((dirty and 0x0B) != 0 || !composer.skipping) { / / Actual body of the function Text("Hello $name", composer, / * changed * / 0x0) } else { composer.skipToGroupEnd() } // End of composable group, with recording of the recomposition lambda composer.endRestartGroup() ?. updateScope { comp -> Example(name, comp, dirty or 0x01) } }
  13. fun Example(name: String, composer: Composer?, changed: Int) { // Start

    of composable group, unique key associated with Example() val startToken = composer.startRestartGroup( /* key */ 123456) // Propagation of change bitmask for the 'name' parameter var dirty = changed if (dirty and 0x0E = = 0) { dirty = dirty or if (composer.changed(name)) 0x02 else 0x04 } // Change verification: if no change and skipping possible, we skip the body if ((dirty and 0x0B) != 0 || !composer.skipping) { / / Actual body of the function Text("Hello $name", composer, / * changed * / 0x0) } else { composer.skipToGroupEnd() } // End of composable group, with recording of the recomposition lambda composer.endRestartGroup() ?. updateScope { comp -> Example(name, comp, dirty or 0x01) } }
  14. fun Example(name: String, composer: Composer?, changed: Int) { // Start

    of composable group, unique key associated with Example() val startToken = composer.startRestartGroup( /* key */ 123456) // Propagation of change bitmask for the 'name' parameter var dirty = changed if (dirty and 0x0E = = 0) { dirty = dirty or if (composer.changed(name)) 0x02 else 0x04 } // Change verification: if no change and skipping possible, we skip the body if ((dirty and 0x0B) != 0 || !composer.skipping) { / / Actual body of the function Text("Hello $name", composer, / * changed * / 0x0) } else { composer.skipToGroupEnd() } // End of composable group, with recording of the recomposition lambda composer.endRestartGroup() ?. updateScope { comp -> Example(name, comp, dirty or 0x01) } }
  15. fun Example(name: String, composer: Composer?, changed: Int) { // Start

    of composable group, unique key associated with Example() val startToken = composer.startRestartGroup( /* key */ 123456) // Propagation of change bitmask for the 'name' parameter var dirty = changed if (dirty and 0x0E = = 0) { dirty = dirty or if (composer.changed(name)) 0x02 else 0x04 } // Change verification: if no change and skipping possible, we skip the body if ((dirty and 0x0B) != 0 || !composer.skipping) { / / Actual body of the function Text("Hello $name", composer, / * changed * / 0x0) } else { composer.skipToGroupEnd() } // End of composable group, with recording of the recomposition lambda composer.endRestartGroup() ?. updateScope { comp -> Example(name, comp, dirty or 0x01) } }
  16. Packaging •🤖 Android apk or .aab •🍎 iOS .framework or

    .xcframework. •🖥 Desktop .jar or native installer
  17. Once the application is compiled, the Compose runtime takes care

    of executing the Composable functions and updating the user interface. This runtime is based on a central concept. The di f ferent components are: • Slot Table • Composer • Recomposer Runtime
  18. @Composable fun CounterScreen() { var counter by remember { mutableStateOf(0)

    } Column { Text("Count: $counter") Button( onClick = { counter ++ }, ) { Text("Increment") } } } Runtime
  19. Runtime Concept Role Store the value ? Recompose? mutableStateOf Create

    an observable state ❌ ✅ Remember Store a value between two recompositions ✅ ❌
  20. Runtime 🤖 Rendering on Android Jetpack Compose doesn't rely on

    traditional Android Views to draw each widget. It operates primarily through a hierarchy of Compose-speci fi c LayoutNodes and draws on the Android Canvas. 🍎 Rendering on iOS The Compose UI is drawn using the Skia graphics library, rendered on a native iOS surface. JetBrains provides an integration layer called Skiko for this. Compose draws UI elements on a Skia Canvas and relies on a "container" UIView.
  21. Compose Multiplatform Runtime 🤖 Android Kotlin/JVM Compilation 1 Compose Compiler

    Plugin 2 DEX/APK Generation 3 commonMain + androidMain compiled to JVM bytecode Transformation des @Composable avec optimisations Android spécifiques Bytecode → DEX → APK avec Android Gradle Plugin 🔧 Technical Stack ART Runtime Canvas API Skia Engine Android Views 🎯 Specific • Interop with Views natives • Architecture Components • Material Design • ART optimizations
  22. Compose Multiplatform Runtime 🍎 iOS Kotlin/Native Compilation 1 Compose Compiler

    + Skiko 2 iOS Framework/App 3 commonMain + iosMain compiled to native (LLVM) Transform + Skia integration for iOS Build iOS Framework or full app 🔧 Technical Stack LLVM Skia iOS UIKit Interop Metal API 🎯 Specific • Interop with UIKit • Bridge with SwiftUI • iOS Design Guidelines • Optimize for Metal
  23. Compose Multiplatform Runtime 🖥 Desktop Kotlin/JVM Compilation 1 Compose Desktop

    Runtime 2 JAR/Native Package 3 commonMain + desktopMain compiled to JVM bytecode Skia-JVM and Swing/AWT integration JAR or native packages (DMG, MSI, DEB) 🔧 Technical Stack JVM Skia JVM SWING / AWT OpenGL 🎯 Specific • Interop Swing/AWT • Native menu bars • Multi-window • Keyboard shortcuts
  24. GrooveBox™ 🔗 AudioPlayer interface AudioPlayer { val position: StateFlow<Long> val

    duration: Long val currentVolume: Float suspend fun load(track: Track) suspend fun seekTo(positionMs: Long) suspend fun isMuted(): Boolean fun play() fun pause() fun stop() fun release() }
  25. GrooveBox™ 🤖 AudioPlayer class DefaultAudioPlayer( private val context: Context )

    : AudioPlayer { private var exoPlayer: ExoPlayer? = null private val _position = MutableStateFlow(0L) private val handler = Handler(Looper.getMainLooper()) private var positionUpdateJob: Job? = null override val position: StateFlow<Long> = _position.asStateFlow() override val duration: Long get() = exoPlayer ?. duration ?. takeIf { it != C.TIME_UNSET } ? : 0L override val currentVolume: Float get() = exoPlayer ?. volume ?: 1.0f private val playerListener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_IDLE -> { stopPositionUpdates()
  26. GrooveBox™ 🍎 AudioPlayer import platform.AVFAudio.AVAudioSession import platform.AVFAudio.AVAudioSessionCategoryPlayback import platform.AVFAudio.setActive import

    platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayerItem import platform.AVFoundation.AVPlayerItemFailedToPlayToEndTimeNotification import platform.AVFoundation.AVPlayerItemStatusReadyToPlay import platform.AVFoundation.AVPlayerItemStatusUnknown import platform.AVFoundation.AVPlayerTimeControlStatusPlaying import platform.AVFoundation.AVURLAsset import platform.AVFoundation.addPeriodicTimeObserverForInterval import platform.AVFoundation.currentTime import platform.AVFoundation.duration import platform.AVFoundation.isMuted import platform.AVFoundation.pause import platform.AVFoundation.play import platform.AVFoundation.removeTimeObserver import platform.AVFoundation.replaceCurrentItemWithPlayerItem import platform.AVFoundation.seekToTime import platform.AVFoundation.timeControlStatus
  27. GrooveBox™ 🍎 AudioPlayer override fun play() { avPlayer.play() startPositionUpdates() }

    override fun pause() { avPlayer.pause() stopPositionUpdates() } override fun stop() { avPlayer.pause() val zeroTime = CMTimeMakeWithSeconds(0.0, 1000) avPlayer.seekToTime(zeroTime) _position.value = 0L stopPositionUpdates() } override fun release() {
  28. GrooveBox™ 🖥 AudioPlayer private val playerListener = object : BasicPlayerListener

    { override fun opened(stream: Any?, properties: Map <* , *> ?) { properties ?. let { props -> val durationMicros = props["duration"] as? Long val durationMs = props["audio.length.frames"] as? Long val frameRate = props["audio.framerate.fps"] as? Float // Calculate duration in milliseconds trackDuration = when { durationMicros != null -> durationMicros / 1000 durationMs != null && frameRate != null -> ((durationMs / frameRate) * 1000).toLong() else -> 0L } } isPlayerReady = true
  29. Tips for Compose Multiplatform class JVMPlatform : Platform { override

    val name: String = "Java ${System.getProperty("java.version")}" } actual fun getPlatform(): Platform = JVMPlatform() Fast UI iteration Add a Desktop target + enable Compose Hot Reload Use Live Edit for Android screens Adopt the latest preview tooling
  30. class JVMPlatform : Platform { override val name: String =

    "Java ${System.getProperty("java.version")}" } actual fun getPlatform(): Platform = JVMPlatform()
  31. Tips for Compose Multiplatform class JVMPlatform : Platform { override

    val name: String = "Java ${System.getProperty("java.version")}" } actual fun getPlatform(): Platform = JVMPlatform() Dependency injection
  32. Tips for Compose Multiplatform class JVMPlatform : Platform { override

    val name: String = "Java ${System.getProperty("java.version")}" } actual fun getPlatform(): Platform = JVMPlatform() Dependency injection
  33. Preserve downloaded and cached components between build s . Build

    only for necessary target Tips for Compose Multiplatform iOS integration
  34. Preserve downloaded and cached components between build s . Build

    only for necessary target Don't use transitive expor t Tips for Compose Multiplatform iOS integration
  35. Preserve downloaded and cached components between build s . Build

    only for necessary target Don't use transitive expor t Export an “umbrella” XCFramework if you have many modules Tips for Compose Multiplatform iOS integration
  36. Tips for Compose Multiplatform class JVMPlatform : Platform { override

    val name: String = "Java ${System.getProperty("java.version")}" } actual fun getPlatform(): Platform = JVMPlatform() iOS integration Preserve downloaded and cached components between build s . Build only for necessary target Don't use transitive expor t Export an “umbrella” XCFramework if you have many modules
  37. Preserve downloaded and cached components between build s . Build

    only for necessary target Don't use transitive expor t Export an “umbrella” XCFramework if you have many modules ComposeUIViewController(con fi gure = { parallelRendering = true }) { Tips for Compose Multiplatform iOS integration