Unifying Video Players: Compose Multiplatform for iOS, Android & Desktop

Kashif Mehmood
ProAndroidDev
Published in
5 min readJul 24, 2023

--

Unlocking Cross-Platform Delight: Using platform-specific views in compose multiplatform.

Many of you who have worked with XML and the old ExoPlayer days know how challenging it was to integrate a video player into an app. Fortunately, with Jetpack Compose, things have improved significantly. But even better than Compose itself is Compose Multiplatform, which empowers Android developers to become ‘iOS lite’ developers.

However, in this puzzle, some pieces are missing, and as Doctor Strange says, “In the grand calculus of the multiverse, some views should stay native, or else there will be no nativeness.”. One of these views is a video player; it must be platform-specific, or the app will lack the native feel of each platform. No one wants to use an iPhone and get an Android-looking video player.

So, let's start.

Expect Actual Mechanism:

If you have prior experience with KMM, you might be familiar with the expect/actual mechanism. We can use it to create a VideoPlayer function as an expect function, and each platform will provide its actual implementation.

@Composable
expect fun VideoPlayer(modifier: Modifier, url: String)

In this approach, passing the Modifier as the first parameter to every Composable function is considered a good practice. It allows you to customize the component without altering its inner modifier chain.

Each platform (Android, iOS, etc.) will then implement the actual VideoPlayer function with platform-specific code while adhering to the same expect function signature. Add the actual function to all platforms.

@Composable
actual fun VideoPlayer(modifier: Modifier, url: String) {
// Platform-specific implementation here
}

Android Implementation:

As an Android developer, you might be familiar with ExoPlayer, a media player library for Android.

Step 1:

Add ExoPlayer’s dependencies to the Android Main in your build.gradle file of shared module

     val androidMain by getting {
dependencies {
implementation("androidx.media3:media3-exoplayer:1.1.0")
implementation("androidx.media3:media3-exoplayer-dash:1.1.0")
implementation("androidx.media3:media3-ui:1.1.0")
}
}

Now, you have to add the actual implementation of your Video player. For that, you have to go to your Android main where you created the actual function and add this code.

@Composable
actual fun VideoPlayer(modifier: Modifier, url: String){
AndroidView(
modifier = modifier,
factory = { context ->
VideoView(context).apply {
setVideoPath(url)
val mediaController = MediaController(context)
mediaController.setAnchorView(this)
setMediaController(mediaController)
start()
}
},
update = {})
}

In the actual implementation of the video player for Android, we’ll use the AndroidView composable to wrap the ExoPlayer functionality inside an Android View. The VideoPlayer for Android is using ExoPlayer internally through the VideoView wrapped inside the AndroidView composable, allowing us to play videos in compose multiplatform.

Fairly simple?

IOS Implementation:

In our iOS implementation, we can integrate AVPlayer and UIKit views using Kotlin’s C-Interop. First, we create the AVPlayer instance with the provided URL and use AVPlayerLayer and AVPlayerViewController. AVPlayerViewController handles playback controls and provides a native feel. AVPlayer is similar to ExoPlayer in Android.

 val player = remember { AVPlayer(uRL = NSURL.URLWithString(url)!!) }
val playerLayer = remember { AVPlayerLayer() }
val avPlayerViewController = remember { AVPlayerViewController() }
avPlayerViewController.player = player
avPlayerViewController.showsPlaybackControls = true
playerLayer.player = player

The UIKitView composable is used to integrate AVPlayerViewController with existing UIKit views. The player’s container view is created, and AVPlayerViewController’s view is added as a subview. The onResize callback ensures the player’s frame is adjusted correctly. When the view is updated, the player starts playing. You can see here the modifier that we passed can be used directly on UIKitView

 // Use a UIKitView to integrate with your existing UIKit views
UIKitView(
factory = {
// Create a UIView to hold the AVPlayerLayer
val playerContainer = UIView()
playerContainer.addSubview(avPlayerViewController.view)
// Return the playerContainer as the root UIView
playerContainer
},
onResize = { view: UIView, rect: CValue<CGRect> ->
CATransaction.begin()
CATransaction.setValue(true, kCATransactionDisableActions)
view.layer.setFrame(rect)
playerLayer.setFrame(rect)
avPlayerViewController.view.layer.frame = rect
CATransaction.commit()
},
update = { view ->
player.play()
avPlayerViewController.player!!.play()
},
modifier = modifier)

Our Final function should look like this,

@Composable
actual fun VideoPlayer(modifier: Modifier, url: String) {
val player = remember { AVPlayer(uRL = NSURL.URLWithString(url)!!) }
val playerLayer = remember { AVPlayerLayer() }
val avPlayerViewController = remember { AVPlayerViewController() }
avPlayerViewController.player = player
avPlayerViewController.showsPlaybackControls = true

playerLayer.player = player
// Use a UIKitView to integrate with your existing UIKit views
UIKitView(
factory = {
// Create a UIView to hold the AVPlayerLayer
val playerContainer = UIView()
playerContainer.addSubview(avPlayerViewController.view)
// Return the playerContainer as the root UIView
playerContainer
},
onResize = { view: UIView, rect: CValue<CGRect> ->
CATransaction.begin()
CATransaction.setValue(true, kCATransactionDisableActions)
view.layer.setFrame(rect)
playerLayer.setFrame(rect)
avPlayerViewController.view.layer.frame = rect
CATransaction.commit()
},
update = { view ->
player.play()
avPlayerViewController.player!!.play()
},
modifier = modifier)
}

and we are done. You can use all the available uikit views using this method such as audio player, camera, etc.

and we are done.

Desktop Implementation:

Integrating video playback in Compose Desktop is a bit intricate but achievable. We use SwingPanel, adding VLC dependency to Desktop Main

    SwingPanel(
factory = factory,
background = Color.Transparent,
modifier = modifier,
update = {

}
)
    val desktopMain by getting {
dependencies {

implementation("uk.co.caprica:vlcj:4.7.0")

}
}

Here’s a simple VideoPlayer implementation with VLCJ. It initializes the media player, plays the video URL, and handles macOS-specific player components. Now you can enjoy video playback on Compose Desktop! 🎉❤️


@Composable
fun VideoPlayerImpl(
url: String,
modifier: Modifier,
) {
val mediaPlayerComponent = remember { initializeMediaPlayerComponent() }
val mediaPlayer = remember { mediaPlayerComponent.mediaPlayer() }

val factory = remember { { mediaPlayerComponent } }

LaunchedEffect(url) { mediaPlayer.media().play/*OR .start*/(url) }
DisposableEffect(Unit) { onDispose(mediaPlayer::release) }
SwingPanel(
factory = factory,
background = Color.Transparent,
modifier = modifier,
update = {

}
)
}

private fun initializeMediaPlayerComponent(): Component {
NativeDiscovery().discover()
return if (isMacOS()) {
CallbackMediaPlayerComponent()
} else {
EmbeddedMediaPlayerComponent()
}
}


private fun Component.mediaPlayer() = when (this) {
is CallbackMediaPlayerComponent -> mediaPlayer()
is EmbeddedMediaPlayerComponent -> mediaPlayer()
else -> error("mediaPlayer() can only be called on vlcj player components")
}

private fun isMacOS(): Boolean {
val os = System
.getProperty("os.name", "generic")
.lowercase(Locale.ENGLISH)
return "mac" in os || "darwin" in os
}

and done ❤.

The compose multiplatform video player for desktop is experimental and can be found here.

Result:

can be seen in a video on top.

Repo link: https://github.com/Kashif-E/Compose-Multiplatform-Video-Player

Thanks for reading ❤ do clap if you learned something.

let's connect on Twitter and LinkedIn and discuss more ideas.

Featured in Android Weekly .

--

--

Software Engineer | Kotlin Multiplatfrom | Jetpack/Multiplatform Compose |