Skip to content

Instantly share code, notes, and snippets.

@ShivamKumarJha
Created October 26, 2025 14:54
Show Gist options
  • Select an option

  • Save ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 to your computer and use it in GitHub Desktop.

Select an option

Save ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 to your computer and use it in GitHub Desktop.
ExoPlayer media3 powered by Cronet
package com.shivamkumarjha.media_common.model
import com.shivamkumarjha.network_common.HttpRequestData
import com.shivamkumarjha.network_common.ellipsize
import com.shivamkumarjha.network_common.removeDuplicateWordsIgnoreCase
import kotlinx.serialization.Serializable
@Serializable
data class Media(
val httpRequestData: HttpRequestData,
val label: String,
val mediaType: MediaType = MediaType.AUTO,
val hlsExtraction: Boolean = true,
val description: String = "",
)
package com.shivamkumarjha.streamvault.media3
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.rtsp.RtspMediaSource
import androidx.media3.exoplayer.smoothstreaming.SsMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import com.shivamkumarjha.media_common.model.Media
import com.shivamkumarjha.media_common.model.MediaType
import com.shivamkumarjha.network_client.USER_AGENT
import com.shivamkumarjha.network_client.getHost
import com.shivamkumarjha.streamvault.di.CACHE_SIZE
import com.shivamkumarjha.streamvault.di.PlatformComponent
import org.chromium.net.CronetEngine
import java.io.File
import java.util.concurrent.Executor
import java.util.concurrent.Executors
@UnstableApi
class Media3Helper(
private val context: Context,
) {
private companion object {
private const val TAG = "Media3Helper"
private const val TIMEOUT = 15_000
}
fun createMediaSource(
media: Media,
): MediaSource {
Log.d(TAG, "createMediaSource $media")
// Media Item
val mediaUri: Uri = media.httpRequestData.url.toUri()
val mediaItem = MediaItem.Builder()
.setUri(mediaUri)
.setMimeType(
if (media.mediaType == MediaType.MPD)
MimeTypes.APPLICATION_MPD
else
null
)
.build()
val dataSourceFactory = createDataSource(
url = media.httpRequestData.url,
headers = media.httpRequestData.headers,
)
// Some files have incorrect content type hence requiring this.
if (media.mediaType == MediaType.HLS) {
Log.d(TAG, "createMediaSource -> HLS forced")
return HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem)
}
val contentType = Util.inferContentType(mediaUri)
Log.d(TAG, "createMediaSource -> $contentType")
return when (contentType) {
C.CONTENT_TYPE_HLS -> HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem)
C.CONTENT_TYPE_SS -> SsMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem)
C.CONTENT_TYPE_DASH -> DashMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem)
C.CONTENT_TYPE_RTSP -> RtspMediaSource.Factory()
.createMediaSource(mediaItem)
else -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem)
}
}
private fun createDataSource(
url: String,
headers: Map<String, String>,
): DataSource.Factory {
Log.d(TAG, "createDataSource $headers")
// Cronet
val cronetEngine = createCronetEngine(url)
val executor: Executor = Executors.newSingleThreadExecutor()
val cronetDataSourceFactory = CronetDataSource.Factory(cronetEngine, executor)
.setDefaultRequestProperties(headers)
.setUserAgent(USER_AGENT)
.setConnectionTimeoutMs(TIMEOUT)
.setReadTimeoutMs(TIMEOUT)
.setResetTimeoutOnRedirects(true)
.setHandleSetCookieRequests(true)
// Caching
val cache = PlatformComponent.simpleCache
val streamDataSinkFactory = CacheDataSink.Factory().setCache(cache)
return CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(cronetDataSourceFactory)
.setCacheWriteDataSinkFactory(streamDataSinkFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
private fun createCronetEngine(url: String): CronetEngine {
Log.d(TAG, "createCronetEngine")
val childName = "cronet_cache-${url.getHost()}-${System.currentTimeMillis()}"
val cacheDir = File(context.cacheDir, childName)
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
return CronetEngine.Builder(context)
.enableQuic(true)
.setStoragePath(cacheDir.absolutePath)
.setLibraryLoader(null)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE)
.build()
}
}
package com.shivamkumarjha.media_common.model
import kotlinx.serialization.Serializable
@Serializable
enum class MediaType {
AUTO,
HLS,
VIDEO,
AUDIO,
MPD,
LIVE,
}
package com.shivamkumarjha.streamvault.di
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cache.SimpleCache
import org.koin.core.component.KoinComponent
import org.koin.core.scope.Scope
@UnstableApi
object PlatformComponent : KoinComponent {
val simpleCache: SimpleCache by lazy { scope.get() }
private lateinit var scope: Scope
fun startScope() {
scope = getKoin().createScope<SimpleCache>()
}
fun closeScope() {
scope.close()
}
}
package com.shivamkumarjha.streamvault.presentation.source.media
import android.app.PictureInPictureParams
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Build
import android.util.Rational
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toAndroidRectF
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.graphics.toRect
import androidx.core.util.Consumer
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.shivamkumarjha.media_common.model.Media
import com.shivamkumarjha.presentation_theme.findActivity
import com.shivamkumarjha.streamvault.MainActivity
import com.shivamkumarjha.streamvault.di.PlatformComponent
import com.shivamkumarjha.streamvault.media3.Media3Helper
import com.shivamkumarjha.streamvault.presentation.source.media.model.PlaybackState
@Composable
actual fun supportsPip(): Boolean {
return LocalContext.current.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}
@Composable
actual fun rememberIsInPipMode(): Boolean {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) return false
val activity = LocalContext.current.findActivity()
var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) }
DisposableEffect(activity) {
val observer = Consumer<PictureInPictureModeChangedInfo> { info ->
pipMode = info.isInPictureInPictureMode
}
activity.addOnPictureInPictureModeChangedListener(observer)
onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
}
return pipMode
}
@OptIn(UnstableApi::class)
@Composable
actual fun PlayerNative(
sourceId: Int,
requestJson: String,
modifier: Modifier,
) {
LaunchedEffect(sourceId, requestJson) {
PlatformComponent.startScope()
}
val context = LocalContext.current
val media3Helper = remember { Media3Helper(context) }
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
playWhenReady = true
repeatMode = Player.REPEAT_MODE_OFF
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
}
}
val platformPlayerController = remember {
object : PlatformPlayerController {
override fun getCurrentPosition(): Long {
return exoPlayer.currentPosition.coerceAtLeast(0)
}
override fun getDuration(): Long {
return exoPlayer.duration.coerceAtLeast(0)
}
override fun getBufferedPosition(): Long {
return exoPlayer.bufferedPosition.coerceAtLeast(0)
}
override fun getPlaybackState(): PlaybackState? {
return exoPlayer.toPlaybackState()
}
override fun pause() {
exoPlayer.playWhenReady = false
}
override fun play() {
exoPlayer.playWhenReady = true
}
override fun seek(position: Long) {
exoPlayer.seekTo(position)
}
override fun seekPreview(position: Long) {
exoPlayer.playWhenReady = false
exoPlayer.seekTo(position)
}
override fun setMedia(media: Media) {
exoPlayer.apply {
setMediaSource(media3Helper.createMediaSource(media))
prepare()
play()
}
}
override fun release() {
exoPlayer.release()
PlatformComponent.closeScope()
}
}
}
// Render the player UI
PlayerContent(
sourceId,
requestJson,
platformPlayerController,
modifier,
pipEnter = {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val builder = PictureInPictureParams.Builder()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && exoPlayer.videoSize != VideoSize.UNKNOWN) {
val aspectRatio =
Rational(exoPlayer.videoSize.width, exoPlayer.videoSize.height)
builder.setAspectRatio(aspectRatio)
}
modifier.onGloballyPositioned { layoutCoordinates ->
val sourceRect =
layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
builder.setSourceRectHint(sourceRect)
}
context.findActivity().enterPictureInPictureMode(builder.build())
}
} catch (e: Throwable) {
Toast.makeText(
context,
"Pip error! ${e.message}",
Toast.LENGTH_SHORT,
).show()
}
}
) {
AndroidView(
factory = { context ->
PlayerView(context).apply {
keepScreenOn = true
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = exoPlayer
setBackgroundColor(Color.BLACK)
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
actual fun systemBarsVisibility(visible: Boolean) {
val activity = MainActivity.INSTANCE
val window = activity.window
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
if (visible) {
insetsController.apply {
show(WindowInsetsCompat.Type.statusBars())
show(WindowInsetsCompat.Type.navigationBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
}
} else {
insetsController.apply {
hide(WindowInsetsCompat.Type.statusBars())
hide(WindowInsetsCompat.Type.navigationBars())
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment